Skip to content

Add alliance renewal action to Radial Menu#3095

Closed
deshack wants to merge 7 commits intoopenfrontio:mainfrom
deshack:feature/alliance-extend-radial-menu
Closed

Add alliance renewal action to Radial Menu#3095
deshack wants to merge 7 commits intoopenfrontio:mainfrom
deshack:feature/alliance-extend-radial-menu

Conversation

@deshack
Copy link
Contributor

@deshack deshack commented Feb 2, 2026

Description:

The following PR replaces the (disabled) alliance request button with an alliance extension/renewal button when the alliance with the target player is expiring.

Agreeing to renewal via radial menu also hides the message in the EventsDisplay.

image
Screen.Recording.2026-02-07.at.13.14.59.mov

Please complete the following:

  • I have added screenshots for all UI updates
  • I process any text displayed to the user through translateText() and I've added it to the en.json file
  • I have added relevant tests to the test directory
  • I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced

Please put your Discord username so you can be contacted if a bug or regression is found:

deshack_82603

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 2, 2026

Walkthrough

Adds alliance-extension support: core APIs and eligibility checks, GameRunner exposes extension state per interaction, radial menu adds an “extend alliance” item with timer/custom render, client emits SendAllianceExtensionIntentEvent, EventsDisplay reacts, and tests cover the UI states.

Changes

Cohort / File(s) Summary
Client action handler
src/client/graphics/layers/PlayerActionHandler.ts
Added handleExtendAlliance(recipient: PlayerView) which emits SendAllianceExtensionIntentEvent(recipient).
Radial menu UI & elements
src/client/graphics/layers/RadialMenu.ts, src/client/graphics/layers/RadialMenuElements.ts
Added allyExtendElement with customRender, customRenderStateKey, and timerFraction; radial menu now supports per-item timer gradients, stores centroid data attributes, invokes customRender hooks, updates gradients on refresh, and conditionally uses allyExtendElement when inAllianceExtensionWindow.
Client events display
src/client/graphics/layers/EventsDisplay.ts
Registered listener for SendAllianceExtensionIntentEvent to locate the alliance, remove renewal events for that alliance, and request a UI update.
Client tests
tests/client/graphics/RadialMenuElements.test.ts
Added tests verifying presence/absence and enabled/disabled states of ally_extend based on inAllianceExtensionWindow and canExtendAlliance.
Game runner / interaction surface
src/core/GameRunner.ts
Populates interaction.canExtendAlliance; when allied, sets inAllianceExtensionWindow, myPlayerAgreedToExtend, and otherPlayerAgreedToExtend.
Core alliance & game API
src/core/game/AllianceImpl.ts, src/core/game/Game.ts
Added agreedToExtend(player: Player): boolean to AllianceImpl and declared it on MutableAlliance; added canExtendAlliance(other) to Player interface and extension-related flags on PlayerInteraction.
Core player logic
src/core/game/PlayerImpl.ts
Added canExtendAlliance(other: Player): boolean with eligibility checks (distinct players, connected, alive, allied, within expiry window, and not already agreed). Minor precondition updates to canSendAllianceRequest.

Sequence Diagram

sequenceDiagram
    participant UI as Radial Menu
    participant Handler as PlayerActionHandler
    participant Events as Event Bus
    participant Runner as GameRunner
    participant Player as PlayerImpl
    participant Alliance as AllianceImpl

    UI->>Runner: request interaction data (canExtendAlliance, inAllianceExtensionWindow, agreement flags)
    Runner->>Player: ask canExtendAlliance(other)
    Player->>Alliance: query agreedToExtend / expiresAt
    Alliance-->>Player: return agreedToExtend + expiry
    Player-->>Runner: return canExtendAlliance
    Runner-->>UI: return interaction data

    UI->>UI: render item (timer gradient / customRender)
    UI->>Handler: call handleExtendAlliance(recipient)
    Handler->>Events: emit SendAllianceExtensionIntentEvent(recipient)
    Events->>Runner: deliver extension intent
    Runner->>Alliance: inspect / update agreement state
    Alliance-->>Runner: updated state
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🤝 Two SVG hands meet on the wheel,
A fading arc that counts the deal,
A click emits intent into the night,
Core checks whisper if the stars are right,
Alliances breathe and linger bright.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The PR title accurately describes the main change: adding an alliance renewal action to the radial menu, which is the primary focus of the changeset across multiple files.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description check ✅ Passed The PR description clearly explains the feature: replacing the alliance request button with an alliance extension button when alliances are expiring, with UI updates and test coverage documented.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@deshack deshack marked this pull request as draft February 2, 2026 17:40
coderabbitai[bot]
coderabbitai bot previously approved these changes Feb 2, 2026
@deshack
Copy link
Contributor Author

deshack commented Feb 2, 2026

Suggestion by tryout33:

Maybe two handshake icons? Also keep it green.

Both represent one of the players.

If one has accepted renewal, one of the handshake icons has solid white color. The other icon showing the player who hasn't decided yet, is shown pulsing from white to transparent. If both are undecided, both icons are pulsing. So you're immediately visually updated on if the other player has accepted yet.

The green behind the two handshake icons could also show as a timer just like the icon in the name layer does. Like an emptying hourglass

coderabbitai[bot]
coderabbitai bot previously approved these changes Feb 7, 2026
Replace clip-path overlay with CSS linear-gradient on the main arc path
so the entire button is clickable. Use the 30s extension window (not the
full alliance duration) for the timer fraction, and lighten the expired
portion to match the name overlay style. Preserve the gradient fill on
hover/mouseout so the countdown remains visible during interaction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/client/graphics/layers/RadialMenu.ts`:
- Around line 1129-1141: The refresh path in RadialMenu currently always
destroys and recreates icon children (icon.selectAll("*").remove()) before
calling item.customRender(...), which restarts SVG <animate> elements; change
refresh to first compute a small canonical state key (e.g., disabled + relevant
this.params values or a JSON of props) and compare it to a stored data attribute
on the icon (e.g., icon.attr("data-prev-state")); if the key is identical, skip
remove() and skip re-invoking item.customRender to preserve animations,
otherwise update the data-prev-state and either remove+re-render or call
item.customRender with an "update" flag so implementations can update elements
in place instead of recreating them. Ensure you update RadialMenu refresh logic
and the call-site of item.customRender so custom renderers can opt into in-place
updates.

In `@src/client/graphics/layers/RadialMenuElements.ts`:
- Around line 250-309: In customRender (inside RadialMenuElements.ts) skip
creating/attaching the pulse <animate> elements when the image is disabled:
check the disabled flag together with myAgreed/otherAgreed before creating
animLeft/animRight so no animation is appended if disabled is true; optionally,
if you also want to differentiate "me" vs "them", apply a visual flip to
rightImg (e.g., a transform scale(-1,1) or mirrored asset) rather than using the
identical allianceIcon for both.
🧹 Nitpick comments (2)
tests/client/graphics/RadialMenuElements.test.ts (1)

352-442: Good coverage for the new ally_extend element.

The four tests cover visibility (in/out of extension window) and enabled/disabled states cleanly.

One small note: the allyPlayer mock object is copy-pasted across all four tests. You could extract a small helper like createAllyPlayer() to reduce repetition, but this is a minor nit for test code.

♻️ Optional: extract shared mock
+    function createAllyPlayer(): PlayerView {
+      return {
+        id: () => 2,
+        isAlliedWith: vi.fn(() => true),
+        isPlayer: vi.fn(() => true),
+      } as unknown as PlayerView;
+    }
+
     it("should show extend element when inAllianceExtensionWindow is true", () => {
-      const allyPlayer = {
-        id: () => 2,
-        isAlliedWith: vi.fn(() => true),
-        isPlayer: vi.fn(() => true),
-      } as unknown as PlayerView;
+      const allyPlayer = createAllyPlayer();
       mockParams.selected = allyPlayer;

Apply the same pattern for the other three tests.

src/client/graphics/layers/RadialMenu.ts (1)

362-371: Fragile fadedColor string manipulation, duplicated in two places.

The pattern .replace("rgb", "rgba").replace(")", , ${opacity})) assumes d3.interpolateRgb always returns "rgb(r, g, b)". If the input or output ever includes rgba(...), the first replace produces "rgbaa(...)". This logic also appears twice (in renderPaths and refresh).

Consider using d3.color(...).copy({ opacity }) which is already used elsewhere in this file, or extract a small helper.

♻️ Safer color with opacity
+function colorWithOpacity(baseColor: string, fadeFactor: number, opacity: number): string {
+  const parsed = d3.color(baseColor);
+  if (!parsed) return baseColor;
+  const faded = d3.color(d3.interpolateRgb(parsed.toString(), "white")(fadeFactor));
+  return faded?.copy({ opacity })?.toString() ?? baseColor;
+}

Then replace both occurrences:

-const fadedColor = parsed
-  ? d3.interpolateRgb(parsed.toString(), "white")(0.4)
-      .replace("rgb", "rgba")
-      .replace(")", `, ${opacity})`)
-  : normalColor;
+const fadedColor = colorWithOpacity(baseColor, 0.4, opacity);

Also applies to: 1175-1186

@github-project-automation github-project-automation bot moved this from Triage to Development in OpenFront Release Management Feb 7, 2026
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/client/graphics/layers/RadialMenu.ts`:
- Around line 352-401: The rgba string is built via fragile string replaces in
the timer gradient color computation inside arcs.each (variable fadedColor) —
replace that manual string manipulation with d3.color-based handling: compute
the interpolated color (using d3.interpolateRgb or similar), pass its result
into d3.color(...), then call .copy({ opacity }) and .toString() to produce the
final fadedColor; update the code that sets fadedColor in the block that creates
gradientId and path.attr("fill", ...) and make the identical change in the
refresh() method where the same pattern occurs (around the fadedColor
computation near lines ~1179–1184) so both locations use d3.color(...).copy({
opacity }).toString() instead of string replacements.
🧹 Nitpick comments (3)
src/core/GameRunner.ts (1)

210-228: Alliance extension fields look correct.

The logic for inAllianceExtensionWindow, myPlayerAgreedToExtend, and otherPlayerAgreedToExtend is clear and well-structured.

One small nit: the as Player casts on lines 216 and 227 are redundant — other is already typed as Player from line 204. Removing them keeps the code cleaner.

♻️ Remove redundant casts
-      const alliance = player.allianceWith(other as Player);
+      const alliance = player.allianceWith(other);
       if (alliance) {
         actions.interaction.allianceExpiresAt = alliance.expiresAt();
         const inWindow =
           alliance.expiresAt() <=
           this.game.ticks() +
             this.game.config().allianceExtensionPromptOffset();
         actions.interaction.inAllianceExtensionWindow = inWindow;
         actions.interaction.myPlayerAgreedToExtend =
           alliance.agreedToExtend(player);
-        actions.interaction.otherPlayerAgreedToExtend = alliance.agreedToExtend(
-          other as Player,
-        );
+        actions.interaction.otherPlayerAgreedToExtend =
+          alliance.agreedToExtend(other);
       }
src/client/graphics/layers/RadialMenu.ts (2)

1167-1194: Duplicated timer-gradient color logic — extract a helper.

The faded/normal color computation and gradient stop update logic here is nearly identical to lines 352–401 in renderPaths. Extracting a small helper (e.g. computeTimerGradientColors(baseColor, opacity)) that returns { normalColor, fadedColor } would remove the duplication and make both call sites easier to maintain.


354-356: Dead condition: this.params === null is always false here.

Line 354 already guards this.params as truthy, so the this.params === null check on line 356 can never be true inside this block. Just use d.data.disabled(this.params) directly.

♻️ Simplify
     arcs.each((d) => {
       if (d.data.timerFraction && this.params) {
         const fraction = d.data.timerFraction(this.params);
-        const disabled = this.params === null || d.data.disabled(this.params);
+        const disabled = d.data.disabled(this.params);

deshack and others added 3 commits February 7, 2026 13:33
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Preserve SVG animate elements by comparing a canonical state key
before re-invoking customRender. Renderers can opt in via
customRenderStateKey and handle in-place updates via the update flag.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@deshack deshack marked this pull request as ready for review February 7, 2026 13:06
@deshack
Copy link
Contributor Author

deshack commented Feb 7, 2026

Superseded by #3148

@deshack deshack closed this Feb 7, 2026
@github-project-automation github-project-automation bot moved this from Development to Complete in OpenFront Release Management Feb 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Complete

Development

Successfully merging this pull request may close these issues.

1 participant